import { ObservableStore } from '@metamask/obs-store';
import log from 'loglevel';
import BN from 'bn.js';
import createId from '../lib/random-id';
import { bnToHex } from '../lib/util';
import getFetchWithTimeout from '../../../shared/modules/fetch-with-timeout';

import {
  TRANSACTION_CATEGORIES,
  TRANSACTION_STATUSES,
} from '../../../shared/constants/transaction';
import {
  CHAIN_ID_TO_NETWORK_ID_MAP,
  CHAIN_ID_TO_TYPE_MAP,
  GOERLI,
  GOERLI_CHAIN_ID,
  KOVAN,
  KOVAN_CHAIN_ID,
  MAINNET,
  MAINNET_CHAIN_ID,
  RINKEBY,
  RINKEBY_CHAIN_ID,
  ROPSTEN,
  ROPSTEN_CHAIN_ID,
} from '../../../shared/constants/network';
import { NETWORK_EVENTS } from './network';

const fetchWithTimeout = getFetchWithTimeout(30000);

/**
 * This controller is responsible for retrieving incoming transactions. Etherscan is polled once every block to check
 * for new incoming transactions for the current selected account on the current network
 *
 * Note that only the built-in Infura networks are supported (i.e. anything in `INFURA_PROVIDER_TYPES`). We will not
 * attempt to retrieve incoming transactions on any custom RPC endpoints.
 */
const etherscanSupportedNetworks = [
  GOERLI_CHAIN_ID,
  KOVAN_CHAIN_ID,
  MAINNET_CHAIN_ID,
  RINKEBY_CHAIN_ID,
  ROPSTEN_CHAIN_ID,
];

export default class IncomingTransactionsController {
  constructor(opts = {}) {
    const { blockTracker, networkController, preferencesController } = opts;
    this.blockTracker = blockTracker;
    this.networkController = networkController;
    this.preferencesController = preferencesController;

    this._onLatestBlock = async (newBlockNumberHex) => {
      const selectedAddress = this.preferencesController.getSelectedAddress();
      const newBlockNumberDec = parseInt(newBlockNumberHex, 16);
      await this._update({
        address: selectedAddress,
        newBlockNumberDec,
      });
    };

    const initState = {
      incomingTransactions: {},
      incomingTxLastFetchedBlocksByNetwork: {
        [GOERLI]: null,
        [KOVAN]: null,
        [MAINNET]: null,
        [RINKEBY]: null,
        [ROPSTEN]: null,
      },
      ...opts.initState,
    };
    this.store = new ObservableStore(initState);

    this.preferencesController.store.subscribe(
      pairwise((prevState, currState) => {
        const {
          featureFlags: {
            showIncomingTransactions: prevShowIncomingTransactions,
          } = {},
        } = prevState;
        const {
          featureFlags: {
            showIncomingTransactions: currShowIncomingTransactions,
          } = {},
        } = currState;

        if (currShowIncomingTransactions === prevShowIncomingTransactions) {
          return;
        }

        if (prevShowIncomingTransactions && !currShowIncomingTransactions) {
          this.stop();
          return;
        }

        this.start();
      }),
    );

    this.preferencesController.store.subscribe(
      pairwise(async (prevState, currState) => {
        const { selectedAddress: prevSelectedAddress } = prevState;
        const { selectedAddress: currSelectedAddress } = currState;

        if (currSelectedAddress === prevSelectedAddress) {
          return;
        }

        await this._update({
          address: currSelectedAddress,
        });
      }),
    );

    this.networkController.on(NETWORK_EVENTS.NETWORK_DID_CHANGE, async () => {
      const address = this.preferencesController.getSelectedAddress();
      await this._update({
        address,
      });
    });
  }

  start() {
    const { featureFlags = {} } = this.preferencesController.store.getState();
    const { showIncomingTransactions } = featureFlags;

    if (!showIncomingTransactions) {
      return;
    }

    this.blockTracker.removeListener('latest', this._onLatestBlock);
    this.blockTracker.addListener('latest', this._onLatestBlock);
  }

  stop() {
    this.blockTracker.removeListener('latest', this._onLatestBlock);
  }

  async _update({ address, newBlockNumberDec } = {}) {
    const chainId = this.networkController.getCurrentChainId();
    if (!etherscanSupportedNetworks.includes(chainId)) {
      return;
    }
    try {
      const dataForUpdate = await this._getDataForUpdate({
        address,
        chainId,
        newBlockNumberDec,
      });
      this._updateStateWithNewTxData(dataForUpdate);
    } catch (err) {
      log.error(err);
    }
  }

  async _getDataForUpdate({ address, chainId, newBlockNumberDec } = {}) {
    const {
      incomingTransactions: currentIncomingTxs,
      incomingTxLastFetchedBlocksByNetwork: currentBlocksByNetwork,
    } = this.store.getState();

    const lastFetchBlockByCurrentNetwork =
      currentBlocksByNetwork[CHAIN_ID_TO_TYPE_MAP[chainId]];
    let blockToFetchFrom = lastFetchBlockByCurrentNetwork || newBlockNumberDec;
    if (blockToFetchFrom === undefined) {
      blockToFetchFrom = parseInt(this.blockTracker.getCurrentBlock(), 16);
    }

    const { latestIncomingTxBlockNumber, txs: newTxs } = await this._fetchAll(
      address,
      blockToFetchFrom,
      chainId,
    );

    return {
      latestIncomingTxBlockNumber,
      newTxs,
      currentIncomingTxs,
      currentBlocksByNetwork,
      fetchedBlockNumber: blockToFetchFrom,
      chainId,
    };
  }

  _updateStateWithNewTxData({
    latestIncomingTxBlockNumber,
    newTxs,
    currentIncomingTxs,
    currentBlocksByNetwork,
    fetchedBlockNumber,
    chainId,
  }) {
    const newLatestBlockHashByNetwork = latestIncomingTxBlockNumber
      ? parseInt(latestIncomingTxBlockNumber, 10) + 1
      : fetchedBlockNumber + 1;
    const newIncomingTransactions = {
      ...currentIncomingTxs,
    };
    newTxs.forEach((tx) => {
      newIncomingTransactions[tx.hash] = tx;
    });

    this.store.updateState({
      incomingTxLastFetchedBlocksByNetwork: {
        ...currentBlocksByNetwork,
        [CHAIN_ID_TO_TYPE_MAP[chainId]]: newLatestBlockHashByNetwork,
      },
      incomingTransactions: newIncomingTransactions,
    });
  }

  async _fetchAll(address, fromBlock, chainId) {
    const fetchedTxResponse = await this._fetchTxs(address, fromBlock, chainId);
    return this._processTxFetchResponse(fetchedTxResponse);
  }

  async _fetchTxs(address, fromBlock, chainId) {
    const etherscanSubdomain =
      chainId === MAINNET_CHAIN_ID
        ? 'api'
        : `api-${CHAIN_ID_TO_TYPE_MAP[chainId]}`;

    const apiUrl = `https://${etherscanSubdomain}.etherscan.io`;
    let url = `${apiUrl}/api?module=account&action=txlist&address=${address}&tag=latest&page=1`;

    if (fromBlock) {
      url += `&startBlock=${parseInt(fromBlock, 10)}`;
    }
    const response = await fetchWithTimeout(url);
    const parsedResponse = await response.json();

    return {
      ...parsedResponse,
      address,
      chainId,
    };
  }

  _processTxFetchResponse({ status, result = [], address, chainId }) {
    if (status === '1' && Array.isArray(result) && result.length > 0) {
      const remoteTxList = {};
      const remoteTxs = [];
      result.forEach((tx) => {
        if (!remoteTxList[tx.hash]) {
          remoteTxs.push(this._normalizeTxFromEtherscan(tx, chainId));
          remoteTxList[tx.hash] = 1;
        }
      });

      const incomingTxs = remoteTxs.filter(
        (tx) => tx.txParams?.to?.toLowerCase() === address.toLowerCase(),
      );
      incomingTxs.sort((a, b) => (a.time < b.time ? -1 : 1));

      let latestIncomingTxBlockNumber = null;
      incomingTxs.forEach((tx) => {
        if (
          tx.blockNumber &&
          (!latestIncomingTxBlockNumber ||
            parseInt(latestIncomingTxBlockNumber, 10) <
              parseInt(tx.blockNumber, 10))
        ) {
          latestIncomingTxBlockNumber = tx.blockNumber;
        }
      });
      return {
        latestIncomingTxBlockNumber,
        txs: incomingTxs,
      };
    }
    return {
      latestIncomingTxBlockNumber: null,
      txs: [],
    };
  }

  _normalizeTxFromEtherscan(txMeta, chainId) {
    const time = parseInt(txMeta.timeStamp, 10) * 1000;
    const status =
      txMeta.isError === '0'
        ? TRANSACTION_STATUSES.CONFIRMED
        : TRANSACTION_STATUSES.FAILED;
    return {
      blockNumber: txMeta.blockNumber,
      id: createId(),
      chainId,
      metamaskNetworkId: CHAIN_ID_TO_NETWORK_ID_MAP[chainId],
      status,
      time,
      txParams: {
        from: txMeta.from,
        gas: bnToHex(new BN(txMeta.gas)),
        gasPrice: bnToHex(new BN(txMeta.gasPrice)),
        nonce: bnToHex(new BN(txMeta.nonce)),
        to: txMeta.to,
        value: bnToHex(new BN(txMeta.value)),
      },
      hash: txMeta.hash,
      transactionCategory: TRANSACTION_CATEGORIES.INCOMING,
    };
  }
}

function pairwise(fn) {
  let first = true;
  let cache;
  return (value) => {
    try {
      if (first) {
        first = false;
        return fn(value, value);
      }
      return fn(cache, value);
    } finally {
      cache = value;
    }
  };
}
